Skip to content

Go 基准测试:让“更快了”有可以复核的含义 ​

把一次 ns/op 变小叫优化,容易把机器噪声、输入变化甚至编译器删掉代码误认成收益。一份可靠的微基准至少要说清:测了什么操作,输入是否相同,结果有没有被算出来,前后各跑了多少次,以及变化有多大。

本文把同一个字符串拼接函数编译成两个版本,交错运行 12 组,再用固定版本 benchstat 做比较。除此之外,还运行一个故意写错的基准,看编译器如何把被测乘法全部消掉。环境是 Go 1.27.2、Linux/amd64;这些数字只描述本地实验,不外推服务器容量。

从一个“操作”开始,而不是从计时器开始 ​

例子的操作定义是:将 128 个 "go中" 拼成一个字符串,共 640 字节。旧实现循环 +=,新实现用 strings.Builder。输入准备不计时,输出分配与拼接计时。两版使用同一测试验证空输入、空字符串、中文和普通 ASCII 的结果等于 strings.Join(parts, "")。

go
func BenchmarkBuild(b *testing.B) {
    parts := make([]string, 128)
    for i := range parts { parts[i] = "go中" }
    b.ReportAllocs()
    b.SetBytes(int64(len(parts) * len(parts[0])))
    for b.Loop() {
        _ = Build(parts)
    }
}

BenchmarkXxx(*testing.B) 放在 _test.go 文件里。go test -bench 才会运行基准;-run='^$' 跳过普通测试,适合在已经独立验证正确性后做计时。b.Loop 第一次调用时重置计时,返回 false 时停止计时,因此循环前的准备和循环后的清理默认不计入。[1][2]

一次原始输出如下:

text
BenchmarkBuild    14857    7856 ns/op    81.47 MB/s    43208 B/op    127 allocs/op
字段本次含义不代表什么
14857测量过程中完成的操作次数不是样本次数,也不是并发数
7856 ns/op测量耗时除以操作次数不是某个请求的 p99
81.47 MB/s根据 SetBytes(640) 换算的吞吐不是网卡或磁盘真实吞吐
43208 B/op每次操作平均分配的堆字节数不是最终字符串大小,也不是存活内存
127 allocs/op每次操作平均堆分配次数不包含所有栈上临时变量

原始基准有上万次操作,但在 benchstat 里这一整行只是一份测量样本。不要把一行中的循环次数当作“我已经测了上万组”。

testing.B 的常用动作 ​

要解决的问题入口注意事项
让框架决定迭代次数for b.Loop()Go 1.24 加入;不要再嵌套一层循环到 b.N
显示分配-benchmem 或 b.ReportAllocs()本例两者都验证过;观察的是分配,不是 RSS
避免把准备、复位计时ResetTimer、StopTimer、StartTimer明确哪些工作生产中真要做;频繁停表本身也扰动实验
比较输入规模b.Run("size=...", fn)子基准名字保留尺寸、实现等变量,不把不同工作量合并
测并发吞吐b.RunParallel,循环用 pb.Next()工作分配到多 goroutine;总墙钟时间/总操作数不是单请求延迟
报告额外指标b.ReportMetric(value, "items/op")单位与分母要说清,别拿自定义指标冒充框架统计
固定测量预算-benchtime=100ms 或 -benchtime=10000x时间预算与次数预算含义不同;极短测量容易受噪声影响
多次测量-count=12同一批后跑完所有新版本,仍可能受系统漂移影响
指定并行度-cpu=1,2,4它设置不同 GOMAXPROCS;每一种配置分别比较

这些入口的行为以当前 testing 和 go help testflag 为准。尤其 B.Loop 的防消除实现已变化:Go 1.27.2 文档描述编译器对循环内相应变量加入 runtime.KeepAlive intrinsic,而不是把所有函数调用都禁用内联。别从旧版本博客抄来“永远不能内联”的结论。[1]

基准能通过,也可能什么都没测 ​

下面的基准是故意写错的反例:

go
func square(x uint64) uint64 { return x * x }

func BenchmarkElided(b *testing.B) {
    for i := 0; i < b.N; i++ {
        _ = square(uint64(i))
    }
}

结果没有可观察用途,编译器内联后能删掉乘法。本次 3 次测量是 0.3017、0.2945、0.3055 ns/op。单看数字不能证明消除,所以又检查了生成的机器码:

text
XORL CX, CX
JMP ...
INCQ CX
CMPQ ...
JG ...
RET

完整 go tool objdump 输出确实只有循环控制,没有乘法,也没有 square 调用。这个反例测到的主要是空循环。不能把它当作“Go 乘法只需 0.3 ns”的依据。

在当前版本中,循环条件必须写成准确的 for b.Loop() { ... } 形式,编译器才会对该循环体内符合条件的调用参数、结果和赋值变量保持活跃。示例里的 _ = Build(parts) 因而不会把整个调用当成无用结果删除。保护不意味着所有优化都关闭:常量折叠、算法简化和正常内联仍需结合实际代码理解。[1]

文末另有 BenchmarkKept 让累积变量在 b.Loop 中变化,用作防消除写法示范。它与错误版本的输入依赖不同,两个数字不能用来计算“防消除导致慢几倍”。如果必须维护旧式 b.N 基准,可让结果在循环后被明确使用;全局 sink 可能改变逃逸与缓存行为,仍应检查是不是测成了另一件事。

前后比较,先把其他变量固定 ​

这次两个版本由 build tag 选择,函数名、测试、输入和基准完全相同。先各运行正确性测试、race 和 vet,再生成普通优化构建的测试二进制。race 构建只查竞态,从不拿它的时间与普通构建比较。

正在绘制图示…

可切换适合窗口或原始尺寸
查看图源代码
flowchart LR
    A[同一输入与同一基准] --> B[base.test]
    A --> C[optimized.test]
    B --> D[12 组交错测量<br/>同工具链 / GOMAXPROCS=1]
    C --> D
    D --> E[保留全部原始行]
    E --> F[benchstat<br/>效应大小与不确定性]
    F --> G[代表性负载复测<br/>延迟 / CPU / 内存 / 正确性]
放大图示

原始尺寸下可横向或纵向滚动;按 Escape 关闭。

正在绘制图示…

查看图源代码
flowchart LR
    A[同一输入与同一基准] --> B[base.test]
    A --> C[optimized.test]
    B --> D[12 组交错测量<br/>同工具链 / GOMAXPROCS=1]
    C --> D
    D --> E[保留全部原始行]
    E --> F[benchstat<br/>效应大小与不确定性]
    F --> G[代表性负载复测<br/>延迟 / CPU / 内存 / 正确性]

固定记录如下:

条件本次值
Go / OS / 架构Go 1.27.2,Linux 6.18.44,amd64
CPU 标识AMD EPYC 9V74 80-Core Processor
调度配置GOMAXPROCS=1,-test.cpu=1
每份样本预算-test.benchtime=100ms
样本数前后各 12;奇数组先 base,偶数组先 optimized
构建标准优化,不加 -race,不加 -N -l
环境限制共享云机器,不能证明其他进程静止、物理频率恒定或 CPU 独占
benchstatgolang.org/x/perf 固定到 v0.0.0-20261009192801-be2c69fb417e

交错运行降低“旧版先跑时机器较空、新版后跑时机器较忙”的系统性偏差,不能消灭所有噪声。读者要复测自己的输入规模和并发条件;拼接 2 个字符串和拼接 128 个字符串,不一定值得同一种改法。[3]

benchstat 输出究竟允许说到哪一步 ​

本次真实结果:

指标base 中位数optimized 中位数benchstat 报告
耗时8107.5 ns/op,±7%800.4 ns/op,±13%-90.13%,p=0.000,n=12
分配字节42.195 KiB/op,±0%1.867 KiB/op,±0%-95.57%,p=0.000,n=12
分配次数127/op,±0%8/op,±0%-93.70%,p=0.000,n=12

± 是工具给中位数的置信区间摘要,不是单次请求落入的范围。该版 benchstat 默认使用非参数统计,中位数作摘要,Mann–Whitney U 检验做 A/B 比较;默认显著性阈值是 0.05。输出 p=0.000 是格式化结果,不表示概率数学上为零,也不是“优化正确的概率是 100%”。[3]

统计结论仍依赖实验设计:样本应来自可比的工作量,环境偏差不应系统性偏向某个版本。U 检验比较分布,只有在相应形状、位置差异等解释条件下,才适合进一步把结果说成典型耗时的位置变化。此处差异大,分配计数也一致支持“在这个输入上 Builder 少制造了很多中间字符串”;并不证明所有服务会快 90%。

若输出 ~,含义是工具未检出显著差异,不等于证明完全相同。若有差异但只有 0.5%,也要看这条路径在总成本里占多少、代码复杂度增加多少。事先确定样本数,不要反复运行直到 p<0.05 再挑那一次贴出来;一次对比很多指标或基准,还要考虑多重比较带来的假阳性。[3]

微基准通过之后,把优化放回代表性负载:关注吞吐、p95/p99、CPU、存活堆、GC、锁等待与错误率。有时每次操作更省内存,却把大缓冲留在池里,进程常驻内存反而上涨;有时批量吞吐更好,却让第一条请求等更久。微基准负责回答一个小问题,系统验证负责判断改动是否值得上线。

依据 ​

  1. Go 1.27.2 testing,尤其 B.Loop、RunParallel、SetBytes、ReportAllocs;本机同版本 go doc 与 api/go1.24.txt 交叉核对。
  2. Go 1.27.2 cmd/go:测试与基准参数。
  3. benchstat 官方文档;固定源码提交 be2c69fb417e:中位数、置信区间、默认检验、交错运行与多重比较提示。本次工具模块校验和 h1:IB9P+pA897fozv0n9ldn+jTDNWtEMfbomaXXa4dKr74=。
  4. Go 1.27.2 strings.Builder:拼接 API;性能收益仅取本文实测。

完整复现代码、命令与原始测量 ​

使用 Go 1.27.2,把三个 Go 文件、go.mod 与脚本保存在同一目录。bash run.sh out 生成两个测试二进制与原始结果。再用下面命令把 benchstat 安装到本目录,避免全局安装:

bash
mkdir -p bin
GOBIN="$PWD/bin" GOPROXY=https://proxy.golang.org GOSUMDB=sum.golang.org \
  go install golang.org/x/perf/cmd/benchstat@v0.0.0-20261009192801-be2c69fb417e
./bin/benchstat out/base.txt out/optimized.txt

该工具的固定传递依赖为 github.com/aclements/go-moremath v0.0.0-20210112150236-f10218a38794,由模块依赖和校验数据库验证。被测模块本身没有第三方依赖。

go.mod ​

go.mod
module example.com/benchlab

go 1.27.2

build_base.go ​

go
//go:build !optimized

package benchlab

func Build(parts []string) string {
	var out string
	for _, p := range parts {
		out += p
	}
	return out
}

build_opt.go ​

go
//go:build optimized

package benchlab

import "strings"

func Build(parts []string) string {
	var b strings.Builder
	for _, p := range parts {
		b.WriteString(p)
	}
	return b.String()
}

build_test.go ​

代码块 · 37 行 · 点击收起 / 展开
go
package benchlab

import (
	"strings"
	"testing"
)

func TestBuild(t *testing.T) {
	for _, parts := range [][]string{nil, {""}, {"a", "中", "42"}, {"a", "b", "c"}} {
		if got, want := Build(parts), strings.Join(parts, ""); got != want {
			t.Fatalf("got %q want %q", got, want)
		}
	}
}
func BenchmarkBuild(b *testing.B) {
	parts := make([]string, 128)
	for i := range parts {
		parts[i] = "go中"
	}
	b.ReportAllocs()
	b.SetBytes(int64(len(parts) * len(parts[0])))
	for b.Loop() {
		_ = Build(parts)
	}
}
func square(x uint64) uint64 { return x * x }
func BenchmarkElided(b *testing.B) {
	for i := 0; i < b.N; i++ {
		_ = square(uint64(i))
	}
}
func BenchmarkKept(b *testing.B) {
	x := uint64(17)
	for b.Loop() {
		x = square(x + 1)
	}
}

run.sh ​

bash
#!/usr/bin/env bash
set -euo pipefail
export TZ=UTC
out=${1:-out}
mkdir -p "$out"
{ go version; go env GOOS GOARCH CGO_ENABLED GOEXPERIMENT; uname -sr; grep -m1 'model name' /proc/cpuinfo; } > "$out/environment.txt"
for tag in '' optimized; do
 go test -tags="$tag" .
 go test -race -tags="$tag" .
 go vet -tags="$tag" .
done > "$out/test.txt"
go test -c -o "$out/base.test" .
go test -c -tags=optimized -o "$out/optimized.test" .
: > "$out/base.txt"
: > "$out/optimized.txt"
for i in $(seq 1 12); do
 if ((i % 2)); then order='base optimized'; else order='optimized base'; fi
 for name in $order; do
  GOMAXPROCS=1 "$out/$name.test" -test.run='^$' -test.bench='^BenchmarkBuild$' -test.benchtime=100ms -test.cpu=1 >> "$out/$name.txt"
 done
done
GOMAXPROCS=1 "$out/base.test" -test.run='^$' -test.bench='Benchmark(Elided|Kept)$' -test.benchtime=100ms -test.count=3 -test.cpu=1 > "$out/elision.txt"
go tool objdump -s 'example.com/benchlab.BenchmarkElided' "$out/base.test" > "$out/elided-assembly.txt"
go test -run='^$' -bench='^BenchmarkBuild$' -benchmem -benchtime=100ms -cpu=1 > "$out/benchmem.txt"

本次所有拼接测量行 ​

以下只移除了重复的机器标识与 PASS 行,没有删选样本。前 12 行是 base,后 12 行是 optimized。

text
[base]
BenchmarkBuild 	   14857	      7856 ns/op	  81.47 MB/s	   43208 B/op	     127 allocs/op
BenchmarkBuild 	   13351	      9175 ns/op	  69.75 MB/s	   43208 B/op	     127 allocs/op
BenchmarkBuild 	   13530	      8065 ns/op	  79.35 MB/s	   43208 B/op	     127 allocs/op
BenchmarkBuild 	   15705	      7246 ns/op	  88.32 MB/s	   43208 B/op	     127 allocs/op
BenchmarkBuild 	   15253	      7704 ns/op	  83.08 MB/s	   43208 B/op	     127 allocs/op
BenchmarkBuild 	   13712	      8505 ns/op	  75.25 MB/s	   43208 B/op	     127 allocs/op
BenchmarkBuild 	   14859	      7800 ns/op	  82.05 MB/s	   43208 B/op	     127 allocs/op
BenchmarkBuild 	   15031	      8150 ns/op	  78.52 MB/s	   43208 B/op	     127 allocs/op
BenchmarkBuild 	   13634	      8646 ns/op	  74.02 MB/s	   43208 B/op	     127 allocs/op
BenchmarkBuild 	   15294	      7598 ns/op	  84.23 MB/s	   43208 B/op	     127 allocs/op
BenchmarkBuild 	   12559	      9156 ns/op	  69.90 MB/s	   43208 B/op	     127 allocs/op
BenchmarkBuild 	   12865	      8419 ns/op	  76.01 MB/s	   43208 B/op	     127 allocs/op
[optimized]
BenchmarkBuild 	  149546	       799.2 ns/op	 800.78 MB/s	    1912 B/op	       8 allocs/op
BenchmarkBuild 	  153391	       798.8 ns/op	 801.18 MB/s	    1912 B/op	       8 allocs/op
BenchmarkBuild 	  156236	       760.9 ns/op	 841.12 MB/s	    1912 B/op	       8 allocs/op
BenchmarkBuild 	  135510	       836.0 ns/op	 765.58 MB/s	    1912 B/op	       8 allocs/op
BenchmarkBuild 	  137412	       801.5 ns/op	 798.52 MB/s	    1912 B/op	       8 allocs/op
BenchmarkBuild 	  158506	       757.1 ns/op	 845.36 MB/s	    1912 B/op	       8 allocs/op
BenchmarkBuild 	  127860	       822.0 ns/op	 778.56 MB/s	    1912 B/op	       8 allocs/op
BenchmarkBuild 	  160852	       743.4 ns/op	 860.89 MB/s	    1912 B/op	       8 allocs/op
BenchmarkBuild 	  130710	       904.0 ns/op	 708.00 MB/s	    1912 B/op	       8 allocs/op
BenchmarkBuild 	  151222	       781.7 ns/op	 818.75 MB/s	    1912 B/op	       8 allocs/op
BenchmarkBuild 	  107434	       982.6 ns/op	 651.31 MB/s	    1912 B/op	       8 allocs/op
BenchmarkBuild 	  122094	       939.9 ns/op	 680.92 MB/s	    1912 B/op	       8 allocs/op

保留各书来源与许可 · 示例运行条件以正文为准